💡 이전 포스트에서 아래와 같은 피드백을 받았다. 모르는 내용이 많아 리서치 및 정리해봄
feedback: runner.os와 runner.arch까지 hash key에 넣어야 안전할 것 같네요 (pip site packages는 cpu arch를 타므로) 그리고 site packages를 캐시하면 installation time에 실행되어야 하는 script 들은 이후에 skip 될 수 있어서 side effect가 발생할 수 있어서, 보통은 pip cache dir를 캐시하고 이걸 재사용 하는 편입니다. (musl / glibc와 같은 호스트 의존적인 이슈들도 발생할 수 있음)
runner.arch 란?
runner.arch 는 Github actions runner 의 CPU 아키텍처를 의미하는 변수로 ${{ runner.arch }}
처럼 사용된다.
- X64 - Intel/AMD 64비트 (x86_64) - X86 - Intel/AMD 32비트 - ARM - ARM 32비트 - ARM64 - ARM 64비트 (Apple M1/M2, AWS Graviton 등)
실제 yaml 파일에서는 아래 처럼 선언할 수 있다. runner 서버의 os 가 ubuntu 면 runner 의 cpu architecture 는 X64 가 된다.
runs-on: ubuntu-latest # github 에서 제공 -> 항상 X64 (cpu arch) // -> runner.arch = "X64"
만약 runner 가 self-hosted 서버라면 해당 서버가 어떤 아키텍처를 사용하는지에 따라 arch 가 결정된다. self hosted 서버의 경우 아래처럼 옵셔널하게 label 을 지정하여 나타낼 수도 있다.
runs-on: [self-hosted, linux, ARM64] # ARM 서버만 사용 // -> runner.arch = "ARM64"
참고로 ${{ runner.arch }}
가 될 수 있는 값은 아래와 같다.
X64
- Intel/AMD 64비트 (x86_64)X86
- Intel/AMD 32비트ARM
- ARM 32비트ARM64
- ARM 64비트 (Apple M1/M2, AWS Graviton 등)
참고) AMD
, ARM
은 각각 회사 이름이자 아키텍처 이름이다. (Advanced Micro Devices, Advanced RISC Machines)
CPU arch 를 신경써야 하는 이유
numpy 는 순수 python 이 아니라 C 로 작성된 부분이 많은 라이브러리로 아래 구조처럼 C 로 컴파일된 바이너리 파일을 가지고 있다.
numpy/ ├── __init__.py # Python 코드 ├── core/ │ ├── _multiarray_umath.so # C로 컴파일된 바이너리 │ ├── _multiarray_tests.so # C로 컴파일된 바이너리 │ └── ... └── linalg/ ├── _umath_linalg.so # C로 컴파일된 바이너리 └── ... > so stands for shared object = Linux 의 동적 라이브러리 > `.so` 파일은 기계어를 의미한다.
그리고 numpy 를 사용하는 파이썬 코드의 동작 방식을 간단하게 나타내면 아래와 같다.
# __init__.py import numpy as np arr = np.array([1, 2, 3]) 위 파이썬 코드가 실행되면 내부적으로: numpy/__init__.py (Python) ↓ numpy/core/_multiarray_umath.so (기계어) ↓ CPU 명령어 실행
문제는 동일한 numpy 라이브러리 코드라 하더라도 기계어는 CPU 아키텍처 마다 별도로 존재한다! 기계어는 CPU 가 직접 실행하는 명령어기 때문에 CPU 아키텍처에 종속적이다. 개발자가 작성하는 코드는 보통 high level(e.g Python) 언어이기 때문에 cpu 아키텍처와 독립적이며 보통 이를 신경쓰지 않고 코드를 작성한다. 하지만 numpy 처럼 성능을 위해 C 로 작성된 확장 모듈(.so)은 컴파일된 기계어를 포함하므로 x86_64
에서 빌드된 바이너리를 ARM64
에서 실행할 수 없다.
따라서 캐시 키에 runner.arch를 포함해 아키텍처별로 바이너리를 구분해야 한다.
site-packages 를 그대로 캐싱하면 위험한 이유
CI 시간을 단축하기 위해 pip 다운로드 캐시 뿐만 아니라 설치된 패키지(site-packages)까지 캐싱하면 어떤 문제가 생길까?
runner 서버의 아키텍처가 x86_64 일 때 pip install 을 통해 설치한 numpy 를 캐싱했다고 가정해보자. 캐싱된 패키지의 기계어는 오직 x86_64 CPU 에서만 실행된다.
개발자가 모르는 사이에 데브옵스 팀에서 runner 서버의 아키텍처만 ARM64 (혹은 그 외)로 변경하면 어떻게 될까? 이미 x86_64 용으로 컴파일된 기계어(.so)가 캐시 히트 되지만 ARM64 CPU 는 해당 코드(기계어)를 실행할 수 없고 Exec format error! 같은 에러 메시지만 보게 된다.
┌─────────────────────────────────────┐ │ 1. pip install numpy (x86_64 서버) │ └────────────┬────────────────────────┘ ↓ ┌─────────────────────────────────────┐ │ 2. numpy wheel 다운로드/컴파일 │ │ → _multiarray_umath.so (x86_64) │ └────────────┬────────────────────────┘ ↓ ┌─────────────────────────────────────┐ │ 3. site-packages에 설치 │ │ /site-packages/numpy/ │ │ ├── __init__.py (Python) │ │ └── core/_multiarray_umath.so │ │ (x86_64 기계어) ⭐ │ └────────────┬────────────────────────┘ ↓ ┌─────────────────────────────────────┐ │ 4. site-packages 캐시 저장 │ │ key: linux-py39-abc123 │ └────────────┬────────────────────────┘ ↓ ┌─────────────────────────────────────┐ │ 5. 새 runner (ARM64) 시작 │ │ 캐시 복원 (같은 키) │ └────────────┬────────────────────────┘ ↓ ┌─────────────────────────────────────┐ │ 6. import numpy 실행 │ │ → _multiarray_umath.so 로드 시도 │ │ → x86_64 명령어를 ARM64에서? │ │ 💥 Exec format error! │ └─────────────────────────────────────┘
runner.arch
까지 고려한 안전한 캐싱 전략
1: 캐시 키에 아키텍처 포함
아래 처럼 아키텍처 별로 캐시를 분리하면 runner.arch 가 변경될 경우 캐시 미스가 발생하기 때문에 패키지를 재설치해 변경된 아키텍처에 맞게 기계어를 컴파일 한다.
- name: Setup pip packages cache uses: actions/cache@v3 with: path: /usr/local/lib/python2.7/site-packages key: ${{ runner.os }}-${{ runner.arch }}-py27-packages-v2-${{ hashFiles('requirements.txt') }} # 예: linux-X64-py27-packages-v2-abc123 # linux-ARM64-py27-packages-v2-abc123
2: pip 다운로드 캐시만 사용 (안전)
캐시 키에 아키텍처를 포함하여 아키텍처 변경 시 발생하는 문제를 예방할 수 있지만, 사실 가장 안전한 방법은 pip 다운로드 캐시만 사용하는 것이다.
- name: Setup pip download cache uses: actions/cache@v3 with: path: ${{ github.workspace }}/.cache/pip key: ${{ runner.os }}-${{ runner.arch }}-pip-v4-${{ hashFiles('requirements.txt') }}
위 처럼 개선 시 여전히 pip 다운로드는 캐시되지만 매번 패키지 설치 단계는 실행되어 ci 시간은 더 걸릴 수 있다.
하지만 피드백 내용처럼 setup.py
같은 설치 스크립트(post-install)가 존재할 경우 캐싱된 라이브러리에서는 이게 제대로 동작하지 않을 수도 있으므로,
매번 패키지 설치를 진행하는 것이 오히려 확실히 안전성을 보장할 수 있는 방법이다.
혹시나 해서 찾아봤는데 numpy 라이브러리만 봐도 꽤나 많은 post-install setup.py
파일이 존재한다.
그러니 site-packages 를 그대로 캐싱하는 건 사이드 이펙트 발생 여지가 많으므로 pip download 만 캐싱하도록 변경하자.